Skip to content

Offer a footer Cancel on a stepper command dialog - #129

Merged
woksin merged 4 commits into
mainfrom
feat/stepper-footer-cancel
Aug 11, 2026
Merged

Offer a footer Cancel on a stepper command dialog#129
woksin merged 4 commits into
mainfrom
feat/stepper-footer-cancel

Conversation

@woksin

@woksin woksin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Added

Fixed

  • StepperCommandDialog no longer offers to cancel a command it has already committed to running, so an operator can no longer be told a write was cancelled while it lands
  • The header close and Escape are withdrawn while a StepperCommandDialog command is running, matching the footer Cancel

The X in the header was the only way out of a wizard, which is easy to
miss on a long or destructive flow and disappears entirely when the dialog
is presented without a visible header. The cancel path itself already
existed - the X routes through it - so this only surfaces it as a button.

It is opt-in, because turning it on by default would silently re-lay-out
every wizard already in use. The button sits leftmost, before Previous and
outside the spacer, so it stays in the same place when Previous appears on
the second step rather than sliding across as the footer fills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg
@woksin woksin added the minor label Aug 11, 2026
Asserting that a button with the right label renders proves almost
nothing here: the dialog already has a close path, and a cancel that
quietly closed as confirmed would look identical in static markup. These
specs run in a DOM, click the real button, and assert which arm ran - the
cancel callback fired, the confirm callback did not, and the dialog
context closed as cancelled. A control spec drives submit through the same
harness, so "confirm never ran" is falsifiable rather than assumed.

Position is asserted as footer layout including the spacer, not as button
order. Order alone cannot tell "before the spacer" from "after it" on the
first step, where there is no Previous to sit between - which is the one
cell where the claim that cancel stays put actually needed proving.

Busy is asserted behaviorally: a click during execution is ignored, and
honored again once it finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg
@woksin
woksin marked this pull request as ready for review August 11, 2026 12:51
@woksin

woksin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context — resolves #121. Commits 33ded98 (props + button) and 09a399a (specs).

The cancel path already existed

onHide has always routed through handleClose(DialogResult.Cancelled), whose cancel arm already fans out to onCancelonClose → the dialog context. Nothing was invented; this only surfaces that path as a button. showCancel defaults to false — turning it on by default would silently re-lay-out every wizard already in use, which would be a major.

Position: leftmost, before Previous and outside the flex-1 spacer, so it does not slide across when Previous appears on step 2.

Why the specs run in a DOM

The repo default is environment: 'node' with renderToStaticMarkup, which never runs effects and cannot dispatch a click. Under it these specs could prove a button with the right label exists and nothing more — and a cancel that quietly closed as confirmed would look identical. All six files opt into jsdom and click the real button.

The load-bearing assertion is which arm ran: onCancel fired, onConfirm did not, context closed with Cancelled. and_submit_is_clicked_instead.ts exists purely as a control, proving the harness can observe the Ok arm — otherwise "confirm never ran" would be unfalsifiable.

Verification

Orchestrator re-ran independently: tsc clean, 156 files / 425 tests (baseline 150/397 — exactly the 6 files and 28 assertions added). StepperCommandDialog.tsx verified byte-identical to HEAD in the spec commit — the specs changed no production code.

12 mutations, each trap-guarded and restored from a byte backup. Headline: flipping the button's DialogResult.CancelledOk kills 6 assertions including should_close_with_cancelled. Every one of the 28 new assertions dies to at least one break; 26 of 28 die to a single-line break.

Three things stated plainly rather than papered over

M10 is a multi-line mutation. No single-line edit moves Cancel relative to Previous — it takes a 9-line block move. The step-dependence axis is covered single-line (M5, M6); pure reordering is not.

A real gap was found and closed mid-way. Button order alone cannot distinguish "before the spacer" from "after it" on the first step, where there is no Previous between them — exactly the cell where the position claim needed proving. The layout helper now describes the spacer, and every mutation whose target specs changed was re-run.

One assertion is only non-vacuous by proximity. should_ignore_a_cancel_click_while_the_command_runs stays green when the button is deleted entirely (no button → zero calls → satisfied). It is saved by two siblings in the same beforeEach that do redden. Worth knowing before anyone splits that file.

Not pinned

  • Visual order. The specs assert DOM child order; a CSS flex-row-reverse or order: would move Cancel on screen with the DOM untouched and nothing would notice.
  • Busy × middle step is unreachable by constructionisBusy is set only by handleSubmit, wired only to Submit, which renders only on the last step. Covered via a single-step wizard instead.

A candidate vacuity species — the obedient double

Every spec in this folder mocks primereact/button, and the mock forwards disabled to a real <button disabled>. That mock, not PrimeReact, is what gives disabled its meaning. If the shipped Button ignored disabled in favour of some other prop, every busy assertion here would still be green.

Distinct from "asserting through a blind interface" (which never calls the real thing) and from "a downstream stage launders the defect" (where the real downstream refuses the bad value): here the downstream is replaced by a more obedient one, so the spec grades the mock's contract rather than the library's. Any mocked-boundary component spec inherits it. The honest scope of these specs is "the dialog asks for the right thing" — not "the shipped button obeys."

woksin and others added 2 commits August 11, 2026 18:06
The footer Cancel was disabled once the command was executing, but the header
close and Escape were not, and the busy flag was only raised after awaiting a
consumer's onBeforeExecute transform. An async transform therefore left a
window in which the command was already committed to running while Cancel was
still live: the operator cancelled, the dialog closed reporting cancellation,
and the write landed anyway.

Raise the busy flag as the first statement of the submit path and gate the
header close and Escape on it, so the dialog stops offering an outcome it
cannot deliver.

The existing "ignore a cancel click while the command runs" assertion could
not fail. jsdom returns early from click() on a disabled control, so it only
ever observed the disabled attribute -- the same fact its sibling asserts --
and deleting the click handler outright left the second suite green. That
suite now settles the run and clicks again, so the handler is pinned where it
is used, and the misleading assertion is renamed to what it measures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg
showCancel and cancelLabel shipped as public props with no documentation, and
the surrounding prose still claimed cancel is always available via the header
X -- which was never the whole truth and is now wrong in a second way, since
no dismissal route is offered while the command runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg
@woksin

woksin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context for the two commits just pushed. Two fresh independent reviewers (code and security) looked at this branch; both found merge-blocking problems, and this addresses all of them.

The spec that could not fail

should_ignore_a_cancel_click_while_the_command_runs was unfalsifiable. jsdom's HTMLElement.click() returns early on a disabled control, so no click event is ever dispatched — the assertion only ever observed button.disabled === true, which is exactly what its sibling should_disable_cancel_while_the_command_runs already asserted. Both died to the same character.

The second suite made it worse: it carried that assertion with no "honored once it returns" counterpart, so deleting the onClick handler entirely left it green. It only reddened because the first suite happened to live in the same file.

Fixed by settling the run and clicking again inside the second suite, and renaming the overclaiming assertion to should_take_cancel_out_of_the_click_path_while_the_command_runs. It was renamed rather than deleted because it is the "before" half of a window — without it, the new assertion would be satisfied equally by a click that landed while the command was still running.

Independently re-verified before merge. Baseline: 11/11 green. Deleting onClick={() => handleClose(DialogResult.Cancelled)} from the footer button: 2 failed / 9 passed, including should_honor_a_cancel_click_once_the_command_returns at and_the_command_is_executing.ts:235. The hole is closed. The file was restored byte-identically afterwards and confirmed with diff.

Every new assertion, and the single line it dies to

Assertion Dies to
should_take_cancel_out_of_the_click_path… (both suites) :270 disabled={isBusy}
should_honor_a_cancel_click_once_the_command_returns (both suites) :269 onClick={…handleClose(Cancelled)}
should_still_render_a_cancel_to_click :265 {showCancel && (
should_not_have_run_the_command_yet :234 the await on the transform
should_report_no_cancellation_to_the_dialog_context :228 setIsBusy(true);
should_run_the_command_the_submit_committed_to :237 the execute() call
should_withdraw_the_header_close_while_the_command_runs :323 closable={!isBusy}
should_honor_the_header_close_once_the_command_returns :318 onHide={…}
should_dismiss_on_escape_once_the_command_returns :324 closeOnEscape={!isBusy}
should_not_dismiss_on_escape_while_the_command_runs :228 setIsBusy(true);

Both regressions are caught: restoring the old handleSubmit ordering fails should_report_no_cancellation_to_the_dialog_context; restoring hardcoded closable fails four assertions.

One line I could not independently falsify — stating it rather than hiding it

closeOnEscape={!isBusy} at :324 has no mutation that kills it alone. PrimeReact computes isCloseOnEscape = props.closable && props.closeOnEscape && visibleState, so closable={!isBusy} already blocks Escape; flipping closeOnEscape back to always-true produces zero failures. The Escape assertions are still non-vacuous — they die to :228 — but :324 itself is defense in depth, not a uniquely pinned guard.

It was kept deliberately: it states intent and holds the Escape path on its own terms if closable is ever loosened. The mock models PrimeReact's real conjunction rather than a convenient fiction, which is why the gap is visible at all. Happy to drop it if the preference is strict falsifiability over defense in depth — behavior is identical either way.

Gates

yarn lint 0 errors · npx vitest run (full suite, not targeted) 159 files / 436 tests, 0 failed · npx tsc -b 0 errors · markdownlint-cli2 on the changed doc, 0 issues.

Note: tsconfig.json excludes **/for_*/**, so spec files are never type-checked by tsc -b. Pre-existing, not introduced here.

Adjacent, deliberately out of scope

CommandDialog.tsx has the same onBeforeExecute-before-busy shape and no closable/closeOnEscape guard, so both defects likely exist there too. Fixing it would widen this PR well past the stepper; worth its own change.

Not verified

No browser was driven — the evidence is jsdom plus the mutation runs above. The async-transform window was reproduced in a spec, not observed against a real slow command.

@woksin
woksin merged commit b02dc78 into main Aug 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant